'use strict';

const BbPromise = require('bluebird');
const crypto = require('crypto');
const fs = require('fs');
const _ = require('lodash');
const path = require('path');

class AwsCompileFunctions {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;
    const servicePath = this.serverless.config.servicePath || '';
    this.packagePath = this.serverless.service.package.path ||
      path.join(servicePath || '.', '.serverless');

    this.provider = this.serverless.getProvider('aws');

    if (this.serverless.service.provider.versionFunctions === undefined ||
        this.serverless.service.provider.versionFunctions === null) {
      this.serverless.service.provider.versionFunctions = true;
    }

    this.hooks = {
      'package:compileFunctions': () => BbPromise.bind(this)
        .then(this.compileFunctions),
    };
  }

  compileRole(newFunction, role) {
    const compiledFunction = newFunction;
    const unnsupportedRoleError = new this.serverless.classes
      .Error(`Unsupported role provided: "${JSON.stringify(role)}"`);

    switch (typeof role) {
      case 'object':
        if ('Fn::GetAtt' in role) {
          // role is an "Fn::GetAtt" object
          compiledFunction.Properties.Role = role;
          compiledFunction.DependsOn = [role['Fn::GetAtt'][0]];
        } else if ('Fn::ImportValue' in role) {
          // role is an "Fn::ImportValue" object
          compiledFunction.Properties.Role = role;
        } else {
          throw unnsupportedRoleError;
        }
        break;
      case 'string':
        if (role.startsWith('arn:aws')) {
          // role is a statically definied iam arn
          compiledFunction.Properties.Role = role;
        } else if (role === 'IamRoleLambdaExecution') {
          // role is the default role generated by the framework
          compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
          compiledFunction.DependsOn = [
            'IamRoleLambdaExecution',
          ];
        } else {
          // role is a Logical Role Name
          compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
          compiledFunction.DependsOn = [role];
        }
        break;
      default:
        throw unnsupportedRoleError;
    }
  }

  compileFunction(functionName) {
    const newFunction = this.cfLambdaFunctionTemplate();
    const functionObject = this.serverless.service.getFunction(functionName);
    functionObject.package = functionObject.package || {};

    const serviceArtifactFileName = this.provider.naming.getServiceArtifactName();
    const functionArtifactFileName = this.provider.naming.getFunctionArtifactName(functionName);

    let artifactFilePath = functionObject.package.artifact ||
      this.serverless.service.package.artifact;
    if (!artifactFilePath ||
      (this.serverless.service.artifact && !functionObject.package.artifact)) {
      let artifactFileName = serviceArtifactFileName;
      if (this.serverless.service.package.individually || functionObject.package.individually) {
        artifactFileName = functionArtifactFileName;
      }

      artifactFilePath = path.join(this.serverless.config.servicePath
        , '.serverless', artifactFileName);
    }

    if (this.serverless.service.package.deploymentBucket) {
      newFunction.Properties.Code.S3Bucket = this.serverless.service.package.deploymentBucket;
    }

    const s3Folder = this.serverless.service.package.artifactDirectoryName;
    const s3FileName = artifactFilePath.split(path.sep).pop();
    newFunction.Properties.Code.S3Key = `${s3Folder}/${s3FileName}`;

    if (!functionObject.handler) {
      const errorMessage = [
        `Missing "handler" property in function "${functionName}".`,
        ' Please make sure you point to the correct lambda handler.',
        ' For example: handler.hello.',
        ' Please check the docs for more info',
      ].join('');
      return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
    }

    const Handler = functionObject.handler;
    const FunctionName = functionObject.name;
    const MemorySize = Number(functionObject.memorySize)
      || Number(this.serverless.service.provider.memorySize)
      || 1024;
    const Timeout = Number(functionObject.timeout)
      || Number(this.serverless.service.provider.timeout)
      || 6;
    const Runtime = functionObject.runtime
      || this.serverless.service.provider.runtime
      || 'nodejs4.3';

    newFunction.Properties.Handler = Handler;
    newFunction.Properties.FunctionName = FunctionName;
    newFunction.Properties.MemorySize = MemorySize;
    newFunction.Properties.Timeout = Timeout;
    newFunction.Properties.Runtime = Runtime;

    // publish these properties to the platform
    this.serverless.service.functions[functionName].memory = MemorySize;
    this.serverless.service.functions[functionName].timeout = Timeout;
    this.serverless.service.functions[functionName].runtime = Runtime;

    if (functionObject.description) {
      newFunction.Properties.Description = functionObject.description;
    }

    if (functionObject.tags || this.serverless.service.provider.tags) {
      const tags = Object.assign(
        {},
        this.serverless.service.provider.tags,
        functionObject.tags
      );
      newFunction.Properties.Tags = [];
      _.forEach(tags, (Value, Key) => {
        newFunction.Properties.Tags.push({ Key, Value });
      });
    }

    if (functionObject.onError) {
      const arn = functionObject.onError;

      if (typeof arn === 'string') {
        const splittedArn = arn.split(':');
        if (splittedArn[0] === 'arn' && (splittedArn[2] === 'sns' || splittedArn[2] === 'sqs')) {
          const dlqType = splittedArn[2];
          const iamRoleLambdaExecution = this.serverless.service.provider
            .compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;
          let stmt;

          newFunction.Properties.DeadLetterConfig = {
            TargetArn: arn,
          };

          if (dlqType === 'sns') {
            stmt = {
              Effect: 'Allow',
              Action: [
                'sns:Publish',
              ],
              Resource: [arn],
            };
          } else if (dlqType === 'sqs') {
            const errorMessage = [
              'onError currently only supports SNS topic arns due to a',
              ' race condition when using SQS queue arns and updating the IAM role.',
              ' Please check the docs for more info.',
            ].join('');
            return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
          }

          // update the PolicyDocument statements (if default policy is used)
          if (iamRoleLambdaExecution) {
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push(stmt);
          }
        } else {
          const errorMessage = 'onError config must be a SNS topic arn or SQS queue arn';
          return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
        }
      } else if (this.isArnRefGetAttOrImportValue(arn)) {
        newFunction.Properties.DeadLetterConfig = {
          TargetArn: arn,
        };
      } else {
        const errorMessage = [
          'onError config must be provided as an arn string,',
          ' Ref, Fn::GetAtt or Fn::ImportValue',
        ].join('');
        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
      }
    }

    let kmsKeyArn;
    const serviceObj = this.serverless.service.serviceObject;
    if ('awsKmsKeyArn' in functionObject) {
      kmsKeyArn = functionObject.awsKmsKeyArn;
    } else if (serviceObj && 'awsKmsKeyArn' in serviceObj) {
      kmsKeyArn = serviceObj.awsKmsKeyArn;
    }

    if (kmsKeyArn) {
      const arn = kmsKeyArn;

      if (typeof arn === 'string') {
        const splittedArn = arn.split(':');
        if (splittedArn[0] === 'arn' && (splittedArn[2] === 'kms')) {
          const iamRoleLambdaExecution = this.serverless.service.provider
            .compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;

          newFunction.Properties.KmsKeyArn = arn;

          const stmt = {
            Effect: 'Allow',
            Action: [
              'kms:Decrypt',
            ],
            Resource: [arn],
          };

          // update the PolicyDocument statements (if default policy is used)
          if (iamRoleLambdaExecution) {
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
              iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
              [stmt],
              _.isEqual
            );
          }
        } else {
          const errorMessage = 'awsKmsKeyArn config must be a KMS key arn';
          return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
        }
      } else {
        const errorMessage = 'awsKmsKeyArn config must be provided as a string';
        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
      }
    }

    const tracing = functionObject.tracing
      || (this.serverless.service.provider.tracing
        && this.serverless.service.provider.tracing.lambda);

    if (tracing) {
      if (typeof tracing === 'boolean' || typeof tracing === 'string') {
        let mode = tracing;

        if (typeof tracing === 'boolean') {
          mode = 'Active';
        }

        const iamRoleLambdaExecution = this.serverless.service.provider
          .compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;

        newFunction.Properties.TracingConfig = {
          Mode: mode,
        };

        const stmt = {
          Effect: 'Allow',
          Action: [
            'xray:PutTraceSegments',
            'xray:PutTelemetryRecords',
          ],
          Resource: ['*'],
        };

        // update the PolicyDocument statements (if default policy is used)
        if (iamRoleLambdaExecution) {
          iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
            [stmt],
            _.isEqual
          );
        }
      } else {
        const errorMessage = 'tracing requires a boolean value or the "mode" provided as a string';
        throw new this.serverless.classes.Error(errorMessage);
      }
    }

    if (functionObject.environment || this.serverless.service.provider.environment) {
      newFunction.Properties.Environment = {};
      newFunction.Properties.Environment.Variables = Object.assign(
        {},
        this.serverless.service.provider.environment,
        functionObject.environment
      );

      let invalidEnvVar = null;
      _.forEach(
        _.keys(newFunction.Properties.Environment.Variables),
        key => { // eslint-disable-line consistent-return
          // taken from the bash man pages
          if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) {
            invalidEnvVar = `Invalid characters in environment variable ${key}`;
            return false;   // break loop with lodash
          }
          const value = newFunction.Properties.Environment.Variables[key];
          if (_.isObject(value)) {
            const isCFRef = _.isObject(value) &&
              !_.some(value, (v, k) => k !== 'Ref' && !_.startsWith(k, 'Fn::'));
            if (!isCFRef) {
              invalidEnvVar = `Environment variable ${key} must contain string`;
              return false;
            }
          }
        }
      );

      if (invalidEnvVar) {
        return BbPromise.reject(new this.serverless.classes.Error(invalidEnvVar));
      }
    }

    if ('role' in functionObject) {
      this.compileRole(newFunction, functionObject.role);
    } else if ('role' in this.serverless.service.provider) {
      this.compileRole(newFunction, this.serverless.service.provider.role);
    } else {
      this.compileRole(newFunction, 'IamRoleLambdaExecution');
    }

    if (!functionObject.vpc) functionObject.vpc = {};
    if (!this.serverless.service.provider.vpc) this.serverless.service.provider.vpc = {};

    newFunction.Properties.VpcConfig = {
      SecurityGroupIds: functionObject.vpc.securityGroupIds ||
      this.serverless.service.provider.vpc.securityGroupIds,
      SubnetIds: functionObject.vpc.subnetIds || this.serverless.service.provider.vpc.subnetIds,
    };

    if (!newFunction.Properties.VpcConfig.SecurityGroupIds
      || !newFunction.Properties.VpcConfig.SubnetIds) {
      delete newFunction.Properties.VpcConfig;
    }

    if (functionObject.reservedConcurrency || functionObject.reservedConcurrency === 0) {
      // Try convert reservedConcurrency to integer
      const reservedConcurrency = _.parseInt(functionObject.reservedConcurrency);

      if (_.isInteger(reservedConcurrency)) {
        newFunction.Properties.ReservedConcurrentExecutions = reservedConcurrency;
      } else {
        const errorMessage = [
          'You should use integer as reservedConcurrency value on function: ',
          `${newFunction.Properties.FunctionName}`,
        ].join('');

        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
      }
    }

    newFunction.DependsOn = [this.provider.naming.getLogGroupLogicalId(functionName)]
      .concat(newFunction.DependsOn || []);

    if (functionObject.layers && _.isArray(functionObject.layers)) {
      newFunction.Properties.Layers = functionObject.layers;
      /* TODO - is a DependsOn needed?
      newLayer.DependsOn = [NEW LAYER??]
        .concat(newLayer.DependsOn || []);
      */
    }

    const functionLogicalId = this.provider.naming
      .getLambdaLogicalId(functionName);
    const newFunctionObject = {
      [functionLogicalId]: newFunction,
    };

    _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
      newFunctionObject);

    const newVersion = this.cfLambdaVersionTemplate();

    // Create hashes for the artifact and the logical id of the version resource
    // The one for the version resource must include the function configuration
    // to make sure that a new version is created on configuration changes and
    // not only on source changes.
    const fileHash = crypto.createHash('sha256');
    const versionHash = crypto.createHash('sha256');
    fileHash.setEncoding('base64');
    versionHash.setEncoding('base64');

    // Read the file in chunks and add them to the hash (saves memory and performance)
    return BbPromise.fromCallback(cb => {
      const readStream = fs.createReadStream(artifactFilePath);

      readStream.on('data', chunk => {
        fileHash.write(chunk);
        versionHash.write(chunk);
      })
      .on('end', () => {
        cb();
      })
      .on('error', error => {
        cb(error);
      });
    })
    .then(() => {
      // Include function configuration in version id hash (without the Code part)
      const properties = _.omit(_.get(newFunction, 'Properties', {}), 'Code');
      _.forOwn(properties, value => {
        const hashedValue = _.isObject(value) ? JSON.stringify(value) : _.toString(value);
        versionHash.write(hashedValue);
      });

      // Finalize hashes
      fileHash.end();
      versionHash.end();

      const fileDigest = fileHash.read();
      const versionDigest = versionHash.read();

      newVersion.Properties.CodeSha256 = fileDigest;
      newVersion.Properties.FunctionName = { Ref: functionLogicalId };
      if (functionObject.description) {
        newVersion.Properties.Description = functionObject.description;
      }

      // use the version SHA in the logical resource ID of the version because
      // AWS::Lambda::Version resource will not support updates
      const versionLogicalId = this.provider.naming.getLambdaVersionLogicalId(
              functionName, versionDigest);
      const newVersionObject = {
        [versionLogicalId]: newVersion,
      };

      if (this.serverless.service.provider.versionFunctions) {
        _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
          newVersionObject);
      }

      // Add function versions to Outputs section
      const functionVersionOutputLogicalId = this.provider.naming
        .getLambdaVersionOutputLogicalId(functionName);
      const newVersionOutput = this.cfOutputLatestVersionTemplate();

      newVersionOutput.Value = { Ref: versionLogicalId };

      if (this.serverless.service.provider.versionFunctions) {
        _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Outputs, {
          [functionVersionOutputLogicalId]: newVersionOutput,
        });
      }

      return BbPromise.resolve();
    });
  }

  compileFunctions() {
    const allFunctions = this.serverless.service.getAllFunctions();
    return BbPromise.each(
      allFunctions,
      functionName => this.compileFunction(functionName)
    );
  }

  // helper functions
  isArnRefGetAttOrImportValue(arn) {
    return typeof arn === 'object' &&
      _.some(_.keys(arn), (k) => _.includes(['Ref', 'Fn::GetAtt', 'Fn::ImportValue'], k));
  }

  cfLambdaFunctionTemplate() {
    return {
      Type: 'AWS::Lambda::Function',
      Properties: {
        Code: {
          S3Bucket: {
            Ref: 'ServerlessDeploymentBucket',
          },
          S3Key: 'S3Key',
        },
        FunctionName: 'FunctionName',
        Handler: 'Handler',
        MemorySize: 'MemorySize',
        Role: 'Role',
        Runtime: 'Runtime',
        Timeout: 'Timeout',
      },
    };
  }

  cfLambdaVersionTemplate() {
    return {
      Type: 'AWS::Lambda::Version',
      // Retain old versions even though they will not be in future
      // CloudFormation stacks. On stack delete, these will be removed when
      // their associated function is removed.
      DeletionPolicy: 'Retain',
      Properties: {
        FunctionName: 'FunctionName',
        CodeSha256: 'CodeSha256',
      },
    };
  }

  cfOutputLatestVersionTemplate() {
    return {
      Description: 'Current Lambda function version',
      Value: 'Value',
    };
  }
}

module.exports = AwsCompileFunctions;
